<?php
/**
 * @copyright Copyright (c) 2010, Hon Wai, Lau. All rights reserved.
 * @author Hon Wai, Lau <lau65536@gmail.com>
 * @license New and Simplified BSD licenses, http://www.opensource.org/licenses/bsd-license.php
 */


/**
 * This class provides methods to check whether an input unit is convertible to a unit in a list.
 * 
 * A unit is a combination of the 'base units' and its exponents. For the International System of Units
 * (SI), there are 7 base units and some derived units. In comparison, the 'base units' here represents
 * the unit that is not 'compound units', i.e. 'base units' is a string without space.
 * In order to compare whether two string represent the same unit, the method employed here is to
 * decompose both string into 'base units' and exponents and then compare one by one.
 * 
 * In addition, different units can represent the same dimension linked by a conversion factor.
 * All those units are acceptable, so there is a need for a conversion method. To solve this problem,
 * for the same dimensional quantity, user can specify conversion rules between several 'base units'.
 * Also, user are allow to specify one (and only one) conversion rule between different 'compound units'
 * known as the $target variable in the check_convertibility().
 * 
 * Example format of rules, for 'compound unit': "J = N m = kg m^2/s^2, W = J/s = V A, Pa = N m^(-2)"
 * For 'base unit': "1 m = 1e-3 km = 100 cm; 1 cm = 0.3937 inch; 1024 B = 1 KiB; 1024 KiB = 1 MiB"
 * The scale of a unit without a prefix is assumed to be 1. For convenience of using SI prefix, an
 * alternative rules format for 'base unit' is that a string with a unit and colon, then followed by
 * a list of SI prefix separated by a space, e.g. "W: M k m" equal to "W = 1e3 mW = 1e-3kW = 1e-6MW"
 */
class answer_unit_conversion {
    private $mapping;           // mapping of the unit to the (dimension class, scale)
    private $additional_rules;  // additional rules other than the default rules
    private $default_mapping;   // default mapping of a user selected rules, usually Common SI prefix
    private $default_last_id;   // dimension class id counter
    private $default_id;        // id of the default rule
    private $default_rules;     // string of the default rule in a particular format
    public static $unit_exclude_symbols = '][)(}{><0-9.,:;`~!@#^&*\/?|_=+ -';
    public static $prefix_scale_factors = array('d'=>1e-1, 'c'=>1e-2, 'da'=>1e1, 'h'=>1e2,
        'm'=>1e-3, 'µ'=>1e-6, 'n'=>1e-9, 'p'=>1e-12, 'f'=>1e-15, 'a'=>1e-18, 'z'=>1e-21, 'y'=>1e-24,
        'k'=>1e3,  'M'=>1e6,  'G'=>1e9,  'T'=>1e12,  'P'=>1e15,  'E'=>1e18,  'Z'=>1e21,  'Y'=>1e24);
    
    
    /// Initialize the internal conversion rule to empty. No throw
    function __construct() {
        $this->default_id = 0;
        $this->default_rules = '';
        $this->default_mapping = null;
        $this->mapping = null;
        $this->additional_rules = '';
    }
    
    
    /**
     * It assign default rules to this class. It will also reset the mapping. No throw
     * 
     * @param string $default_id id of the default rule. Use to avoid reinitialization same rule set
     * @param string $default_rules default rules
     */
    function assign_default_rules($default_id, $default_rules) {
        if ($this->default_id == $default_id)  return;  // do nothing if the rules is unchange
        $this->default_id = $default_id;
        $this->default_rules = $default_rules;
        $this->default_mapping = null;
        $this->mapping = null;
        $this->additional_rules = '';   // always remove the additional rule
    }
    
    
    /**
     * Add the additional rule other than the default. Note the previous additional rule will be erased.
     * 
     * @param string $additional_rules the additional rule string
     */
    function assign_additional_rules($additional_rules) {
        $this->additional_rules = $additional_rules;
        $this->mapping = null;
    }
    
    
    /**
     * Parse all defined rules. It is designed to avoid unnecessary reparsing. Throw on parsing error
     */
    function reparse_all_rules() {
        if ($this->default_mapping === null) {
            $tmp_mapping = array();
            $tmp_counter = 0;
            $this->parse_rules($tmp_mapping, $tmp_counter, $this->default_rules);
            $this->default_mapping = $tmp_mapping;
            $this->default_last_id = $tmp_counter;
        }
        if ($this->mapping === null) {
            $tmp_mapping = $this->default_mapping;
            $tmp_counter = $this->default_last_id;
            $this->parse_rules($tmp_mapping, $tmp_counter, $this->additional_rules);
            $this->mapping = $tmp_mapping;
        }
    }
    
    
    /// return the current unit mapping in this class
    function get_unit_mapping() {
        return $this->mapping;
    }
    
    
    /// return a dimension classes list for current mapping. Each class is an array of $unit to $scale mapping
    function get_dimension_list() {
        $dimension_list = array();
        foreach ($this->mapping as $unit => $class_scale) {
            list($class, $scale) = $class_scale;
            $dimension_list[$class][$unit] = $scale;
        }
        return $dimension_list;
    }
    
    
    /** 
     * Check whether an input unit is equivalent, under conversion rules, to target units. May throw
     * 
     * @param string $ipunit The input unit string
     * @param string $targets The list of unit separated by "=", such as "N = kg m/s^2"
     * @return object with three field:
     *   (1) convertible: true if the input unit is equivalent to the list of unit, otherwise false
     *   (2) cfactor: the number before ipunit has to multiply by this factor to convert a target unit.
     *     If the ipunit is not match to any one of target, the conversion factor is always set to 1
     *   (3) target: indicate the location of the matching in the $targets, if they are convertible
     */
    function check_convertibility($ipunit, $targets) {
        $l1 = strlen(trim($ipunit)) == 0;
        $l2 = strlen(trim($targets)) == 0;
        if ($l1 && $l2) // if both of them are empty, no unit check is required. i.e. they are equal
            return (object) array('convertible' => true,  'cfactor' => 1, 'target' => 0);
        else if (($l1 && !$l2) || (!$l1 && $l2))    // if one of them is empty, they must not equal
            return (object) array('convertible' => false, 'cfactor' => 1, 'target' => null);
        // Parsing error for $ipunit is counted as not equal because it cannot match any $targets
        $ip = $this->parse_unit($ipunit);
        if ($ip === null)
            return (object) array('convertible' => false, 'cfactor' => 1, 'target' => null);
        $this->reparse_all_rules();   // reparse if the any rules have been updated
        $targets_list = $this->parse_targets($targets);
        $res = $this->check_convertibility_parsed($ip, $targets_list);
        if ($res === null)
            return (object) array('convertible' => false, 'cfactor' => 1, 'target' => null);
        else // for the input successfully converted to one of the unit in the $targets list
            return (object) array('convertible' => true,  'cfactor' => $res[0], 'target' => $res[1]);
    }
    
    
    /**
     * Parse the $targets into an array of target units. Throw on parsing error
     * 
     * @param string $targets The "=" separated list of unit, such as "N = kg m/s^2"
     * @return an array of parsed unit, parsed by the parse_unit().
     */
    function parse_targets($targets) {
        $targets_list = array();
        if (strlen(trim($targets)) == 0)  return $targets_list;
        $units = explode('=', $targets);
        foreach ($units as $unit) {
            if (strlen(trim($unit) ) == 0)  throw new Exception('""');
            $parsed_unit = $this->parse_unit($unit);
            if ($parsed_unit === null)  throw new Exception('"'.$unit.'"');
            $targets_list[] = $parsed_unit;
        }
        return $targets_list;
    }
    
    
    /**
     * Check whether an parsed input unit $a is the same as one of the parsed unit in $target_units. No throw
     * 
     * @param array $a the an array of (base unit => exponent) parsed by the parse_unit() function
     * @param array $targets_list an array of parsed units.
     * @return the array of (conversion factor, location in target list) if convertible, otherwise null
     */
    function check_convertibility_parsed($a, $targets_list) {
        foreach ($targets_list as $i => $t) {   // use exclusion method to check whether there is one match
            if (count($a) != count($t))  continue;  // if they have different number of base unit, skip
            $cfactor = 1.;
            $is_all_matches = true;
            foreach ($a as $name => $exponent) {
                $unit_found = isset($t[$name]);
                $f = 1;
                $e = $t[$name];         // exponent of the target base unit
                if (!$unit_found) {     // if the base unit not match directly, try conversion
                    list($f, $e) = $this->attempt_conversion($name, $t);
                    $unit_found = isset($f);
                }
                if (!$unit_found || abs($exponent-$e) > 0) {
                    $is_all_matches = false; // if unit is not found or the exponent of this dimension is wrong
                    break;  // stop further check
                }
                $cfactor *= pow($f, $e);
            }
            if ($is_all_matches)  return array($cfactor, $i); // all unit name and their dimension matches
        }
        return null;   // none of the possible units match, so they are not the same
    }
    
    
    /**
     * Attempt to convert the $test_unit_name to one of the unit in the $base_unit_array,
     * using any of the conversion rule added in this class earlier. No throw
     * 
     * @param string $test_unit the name of the test unit
     * @param array $base_unit_array in the format of array(unit => exponent, ...)
     * @return array(conversion factor, unit exponent) if it can be converted, otherwise null.
     */
    function attempt_conversion($test_unit_name, $base_unit_array) {
        $oclass = $this->mapping[$test_unit_name];
        if (!isset($oclass))  return null;  // it does not exist in the mapping implies it is not convertible
        foreach ($base_unit_array as $u => $e) {
            $tclass = $this->mapping[$u];   // try to match the dimension class of each base unit
            if (isset($tclass) && $oclass[0] == $tclass[0])  return array($oclass[1]/$tclass[1], $e);
        }
        return null;
    }
    
    
    /**
     * Split the input into the number and unit. No throw
     * 
     * @param string $input physical quantity with number and unit, assume 1 if number is missing
     * @return object with number and unit as the field name. null if input is empty
     */
    function split_number_unit($input) {
        $input = trim($input);
        if (strlen($input) == 0)  return null;
        list($number, $unit) = explode(' ', $input, 2);
        if (is_numeric($number))  return (object) array('number' => floatval($number), 'unit' => $unit);
        else return (object) array('number' => 1, 'unit' => $input);
    }
    
    
    /**
     * Parse the unit string into a simpler pair of base unit and its exponent. No throw
     * 
     * @param string $unit_expression The input unit string
     * @return an array of the form (base unit name => exponent), null on error
     */
    function parse_unit($unit_expression) {
        $unit = array();
        if (strlen(trim($unit_expression)) == 0)  return $unit;
        
        $unit_element_name = '([^'.self::$unit_exclude_symbols.']+)';
        $unit_element_exponent = '([0-9]+|\(([-+0-9]+)\))';
        $unit_element_operator = '\s*([\s\/])\s*';   // Allowed operators are either multiplicative or division
        $unit_element_format = $unit_element_operator.''.$unit_element_name.'(\s*\^\s*'.$unit_element_exponent.')?(.*)';
        $expr = ' ' . $unit_expression; // the first space indicate multiplication! (if no division)
        $one_division = false;  // allow only one division operator only
        while (preg_match( '/^'.$unit_element_format.'$/', ' '.$expr, $matches)) {
            $name = $matches[2];
            $exponent = 1;
            if (strlen($matches[5]) != 0) {
                if (!is_numeric($matches[5]))  break;
                $exponent = floatval($matches[5]);
                if ($exponent >= 0)  break;   // There are no need of m^(0), m^(1), m^(2), ...
            }
            else if (strlen($matches[4]) != 0) {
                if (!is_numeric($matches[4]))  break;
                $exponent = floatval($matches[4]);
                if ($exponent == 0 || $exponent == 1)  break;   // There are no need of m^0, m^1
            }
            if ($matches[1] == '/')  if ($one_division)  break;
            else $one_division = true;
            $unit[$name] = ($matches[1] == '/') ? -$exponent : $exponent;
            if (strlen(trim($matches[6])) == 0)  return $unit;
            $expr = $matches[6];
        }
        return null;  // if anything remain, it is error
    }
    
    
    /**
     * Parse rules into an mapping that will be used for fast lookup of unit. Throw on parsing error
     * 
     * @param array $mapping an empty array, or array of unit => array(dimension class, conversion factor)
     * @param int $dim_id_count current number of dimension class. It will be incremented for new class
     * @param string $rules_string a comma separated list of rules
     */
    function parse_rules(&$mapping, &$dim_id_count, $rules_string) {
        $rules = explode(';', $rules_string);
        foreach ($rules as $rule)  if (strlen(trim($rule)) > 0) {
            $unit_scales = array();
            $e = explode(':', $rule);
            if (count($e) > 3)  throw new Exception('Syntax error of SI prefix');
            else if (count($e) == 2) {
                $unit_name = trim($e[0]);
                if (preg_match('/['.self::$unit_exclude_symbols.']+/',$unit_name))
                    throw new Exception('"'.$unit_name.'" unit contains unaccepted character.');
                $unit_scales[$unit_name] = 1.0;    // the original unit
                $si_prefixes = explode(' ',$e[1]);
                foreach ($si_prefixes as $prefix)  if (strlen($prefix) != 0) {
                    $f = self::$prefix_scale_factors[$prefix];
                    if (!isset($f))  throw new Exception('"'.$prefix.'" is not SI prefix.');
                    $unit_scales[$prefix.$unit_name] = $f;
                }
            }
            else {
                $data = explode('=', $rule);
                foreach ($data as $d) {
                    $splitted = $this->split_number_unit($d);
                    if ($splitted === null || preg_match('/['.self::$unit_exclude_symbols.']+/',$splitted->unit))
                        throw new Exception('"'.$splitted->unit.'" unit contains unaccepted character.');
                    $unit_scales[trim($splitted->unit)] = 1./floatval($splitted->number);
                }
            }
            $m = $mapping[key($unit_scales)];   // check whether the first unit is already defined
            $dim_id = isset($m) ? $m[0] : $dim_id_count++;  // use existing id if it exists
            $factor = isset($m) ? $m[1]/current($unit_scales) : 1;    // define the relative scale
            foreach ($unit_scales as $unit => $scale)
                $mapping[$unit] = array($dim_id, $factor*$scale);
        }
    }
    
}
?>
